Root Finding and Special Functions¶

AEP 4380¶

Problem Background¶

This assignment was partially about finding access to special scientific functions for numerical analysis, and partially about comparing a couple methods for root finding. In particular: how to access Bessel functions, useful for all sorts of physics, and then comparing Regula Falsi or the False Position Method to the method of Bisection for root finding.

In [3]:
import numpy as np
import matplotlib.pyplot as plt
import scipy as sp
from scipy import special
plt.style.use('dark_background') #Don't judge me
plt.rcParams['figure.figsize'] = [20, 10]

Solution Description¶

First thing's first: turns out scipy has all of the Bessel functions at the ready. It is easy enough to create a helper function which spits out evaluations of the function across a specified range for a specified number of points. What might be even nicer is to be able to specify a range and a tolerance value, and let the function figure out how many points are needed. However, in the end I think the former is sufficient.

In [4]:
def gen_plot_bessel(xmin,xmax,points,func_eval,order):
    output = [0]*points
    output_x = [0]*points
    delta = (xmax-xmin)/(points-1)
    for i in range(0,points-1):
        output_x[i] = xmin+i*delta
        output[i] = func_eval(order, output_x[i])
    output_x[points-1] = xmax
    output[points-1] = func_eval(order, xmax)
    return output_x,output    
In [5]:
#generate points for the Bessel functions of the first and second kind
num = 200
j_0 = gen_plot_bessel(0.0,20,num,sp.special.jn,0)
j_1 = gen_plot_bessel(0.0,20,num,sp.special.jn,1)
j_2 = gen_plot_bessel(0.0,20,num,sp.special.jn,2)
#The second kind blow up near zero, so I just start closeby
y_0 = gen_plot_bessel(0.75,20,num,sp.special.yn,0)
y_1 = gen_plot_bessel(0.75,20,num,sp.special.yn,1)
y_2 = gen_plot_bessel(0.75,20,num,sp.special.yn,2)
In [11]:
plt.plot(j_0[0],j_0[1]);
plt.plot(j_1[0],j_1[1]);
plt.plot(j_2[0],j_2[1]);
plt.title("Bessel functions of the First Kind",fontsize=20);
plt.legend(["$J_0$","$J_1$","$J_2$"],fontsize=20);
No description has been provided for this image
In [26]:
plt.plot(y_0[0],y_0[1])
plt.plot(y_1[0],y_1[1])
plt.plot(y_2[0],y_2[1])
plt.title("Bessel Functions of the Second Kind",fontsize=20);
plt.legend(["$Y_0$","$Y_1$","$Y_2$"],fontsize=20);
No description has been provided for this image

The assignment requires finding the first five smallest values of $x$ such that they satisfy the following relation: $$J_0(x)Y_0(x) = J_2(x)Y_2(x)$$ Which is equivalent to asking for the roots of the difference between the LHS and RHS, or values of $x$ which satisfy: $$J_0(x)Y_0(x) - J_2(x)Y_2(x) = 0$$ I first define a function which will output the result of the equation above called bessel_eqn and plot it along with a horizontal line to show the points that I am aiming to calculate:

In [14]:
bessel_eqn = lambda x: sp.special.jn(2,x)*sp.special.yn(2,x)-sp.special.jn(0,x)*sp.special.yn(0,x)
In [28]:
num=200
bessel_sub_x = np.linspace(0.5,20,num)
bessel_sub = [0]*num
for i in range(0,num):
    bessel_sub[i] = bessel_eqn(bessel_sub_x[i])
plt.plot(bessel_sub_x,bessel_sub);
plt.plot(bessel_sub_x,[0]*num);
plt.title("Bessel Equation",fontsize = 20);
plt.legend(["$J_0(x)Y_0(x) - J_2(x)Y_2(x)$","$y=0$"],fontsize=20);
No description has been provided for this image

Next is to define my root-finding funtions. The first method is called "bisection" and works by going halfway between two points of evaluation which straddle a root. The center evaluation will share a sign with one of the points, defining a new set of bounds which straddle the root. The process is repeated until the root is found to within whatever tolerance is defined at the outset, much like a binary search algorithm. The method takes a function as a parameter, so that it can be applied to any function. The two points which bound a root must be entered to start the algorithm. If the bounds have the same sign, then they must bound either no root or more than one root, which breaks this algorithm.

In [29]:
def root_bisection(xmin,xmax,func_eval,tol):
    num_calls = 0 #just for comparison of efficiency later
    #gather initial point evaluations
    x_low = xmin
    x_high = xmax
    low_val = func_eval(x_low)
    num_calls += 1 
    high_val = func_eval(x_high)
    num_calls += 1 
    x_mid = 0.5*(x_high+x_low)
    mid_val = func_eval(x_mid)
    num_calls += 1 
    #until within tolerance and straddling the root, continue evaluating
    while abs(mid_val)>=tol and ((low_val<=0 and high_val>0) or (low_val>0 and high_val<=0)):
        #continue search for root by making the midpoint the new high or low point
        x_mid = 0.5*(x_high+x_low)
        mid_val = func_eval(x_mid)
        num_calls += 1 
        if (mid_val>=0 and low_val>=0) or (mid_val<=0 and low_val<=0): #same sign as low value
            x_low = x_mid
            low_val = mid_val
        elif (mid_val>=0 and high_val>=0) or (mid_val<=0 and high_val<=0): #same sign as high value
            x_high = x_mid
            high_val = mid_val
    #end search for root
    return x_mid,mid_val,num_calls
In [50]:
tolerance = 1e-7
bis_zero_1 = root_bisection(0.2,1,bessel_eqn,tolerance)
bis_zero_2 = root_bisection(2.5,3,bessel_eqn,tolerance)
bis_zero_3 = root_bisection(3,5,bessel_eqn,tolerance)
bis_zero_4 = root_bisection(5.3,7,bessel_eqn,tolerance)
bis_zero_5 = root_bisection(7,8,bessel_eqn,tolerance)
plt.plot(bessel_sub_x,bessel_sub);
plt.plot(bessel_sub_x,[0]*num);
plt.plot(bis_zero_1[0],bis_zero_1[1],'ro');
plt.plot(bis_zero_2[0],bis_zero_2[1],'ro');
plt.plot(bis_zero_3[0],bis_zero_3[1],'ro');
plt.plot(bis_zero_4[0],bis_zero_4[1],'ro');
plt.plot(bis_zero_5[0],bis_zero_5[1],'ro');
print("Roots: ",bis_zero_1[0],bis_zero_2[0],bis_zero_3[0],bis_zero_4[0],bis_zero_5[0])
plt.title("Roots of the Bessel equation using bisection method",fontsize = 20);
plt.legend(["$J_0(x)Y_0(x) - J_2(x)Y_2(x)$","$y=0$","First Five Roots"],fontsize=20);
Roots:  0.694390869140625 2.9245619773864746 4.574827194213867 6.181685638427735 7.77337646484375
No description has been provided for this image

The second method is called "Regula Falsi" and works by assuming the function is a straight line between the bounds which straddle a root. The zero of that line can be calculated, and then checked to see if it is, in fact, the root. If not, it serves as a new estimate for either the lower or upper bound of the root, and the algorithm repeats.

The slope of the line connecting the function $f(x)$ evaluated at $f(x_{min})$ and $f(x_{max})$ is $$\frac{f(x_{max})-f(x_{min})}{x_{max}-x_{min}}$$ If the straight line crosses zero at $f(x_z)$, then one can start at $x_{min}$ and move to $x_z$ following that line to zero: $$f(x_{min})+\frac{f(x_{max})-f(x_{min})}{x_{max}-x_{min}}(x_z-x_{min}) = 0$$ Rearranging to solve for $x_z$ gives our estimate for the zero crossing, or for the next bound in another call to the same function. $$x_z = x_{min}-\frac{x_{max}-x_{min}}{f(x_{max})-f(x_{min})}f(x_{min})$$

In [32]:
def root_regula_falsi(xmin,xmax,func_eval,tol):
    num_calls = 0 #just for comparison of efficiency later
    #gather initial point evaluations
    x_low = xmin
    x_high = xmax
    low_val = func_eval(x_low)
    num_calls += 1 
    high_val = func_eval(x_high)
    num_calls += 1 
    x_mid = x_low-low_val*(x_high-x_low)/(high_val-low_val)
    mid_val = func_eval(x_mid)
    num_calls += 1 
    #until within tolerance, while straddling the root, keep searching
    while abs(mid_val)>=tol and ((low_val<=0 and high_val>0) or (low_val>0 and high_val<=0)):
        #approximate the root position by assuming there is a straight line between the bounds
        x_mid = x_low-low_val*(x_high-x_low)/(high_val-low_val)
        mid_val = func_eval(x_mid)
        num_calls += 1 
        #use new midpoint as one of the bounds, depending on its sign
        if (mid_val>=0 and low_val>=0) or (mid_val<=0 and low_val<=0): #same sign as low
            x_low = x_mid
            low_val = mid_val
        elif (mid_val>=0 and high_val>=0) or (mid_val<=0 and high_val<=0): #same sign as high
            x_high = x_mid
            high_val = mid_val
    #end search for root
    return x_mid,mid_val,num_calls    
In [51]:
# tolerance = 1e-6
rf_zero_1 = root_regula_falsi(0.2,1,bessel_eqn,tolerance)
rf_zero_2 = root_regula_falsi(2.5,3,bessel_eqn,tolerance)
rf_zero_3 = root_regula_falsi(3,5,bessel_eqn,tolerance)
rf_zero_4 = root_regula_falsi(5.3,7,bessel_eqn,tolerance)
rf_zero_5 = root_regula_falsi(7,8,bessel_eqn,tolerance)
plt.plot(bessel_sub_x,bessel_sub);
plt.plot(bessel_sub_x,[0]*num);
plt.plot(rf_zero_1[0],rf_zero_1[1],'ro');
plt.plot(rf_zero_2[0],rf_zero_2[1],'ro');
plt.plot(rf_zero_3[0],rf_zero_3[1],'ro');
plt.plot(rf_zero_4[0],rf_zero_4[1],'ro');
plt.plot(rf_zero_5[0],rf_zero_5[1],'ro');
print("Roots: ",rf_zero_1[0],rf_zero_2[0],rf_zero_3[0],rf_zero_4[0],rf_zero_5[0])
plt.title("Roots of the Bessel equation using Regula Falsi",fontsize = 20);
plt.legend(["$J_0(x)Y_0(x) - J_2(x)Y_2(x)$","$y=0$","First Five Roots"],fontsize=20);
Roots:  0.6943909809434807 2.9245621822225516 4.574827154845897 6.181686669178312 7.773378537069607
No description has been provided for this image

The secret third way is the "secant method" which works without needing to feed the function with x values which bound the root. Using any two points nearby to a root, it estimates the zero-crossing using the same formula as Regula Falsi, and then just immediately uses that value as one of the new bounds to the function. It can get tricked pretty easily if the points are not chosen with care, since it is possible to have a zero slope and therefore no zero crossing.

In [33]:
def root_secant(xmin,xmax,func_eval,tol):
    num_calls = 0 #just for comparison of efficiency later
    #gather initial point evaluations
    x_low = xmin
    x_high = xmax
    low_val = func_eval(x_low)
    num_calls += 1 
    high_val = func_eval(x_high)
    num_calls += 1 
    x_mid = x_low-low_val*(x_high-x_low)/(high_val-low_val)
    mid_val = func_eval(x_mid)
    num_calls += 1 
    # as long as the value is out of tolerance, continue
    while abs(mid_val)>=tol:
        x_mid = x_low-low_val*(x_high-x_low)/(high_val-low_val)
        mid_val = func_eval(x_mid)
        num_calls += 1 
        #replace low with high, and high with the new value
        x_low = x_high
        low_val = high_val
        x_high = x_mid
        high_val = mid_val
    #end search for root
    return x_mid,mid_val,num_calls  
In [52]:
# tolerance = 1e-6
sec_zero_1 = root_secant(0.2,1,bessel_eqn,tolerance)
sec_zero_2 = root_secant(2.5,3,bessel_eqn,tolerance)
sec_zero_3 = root_secant(3,5,bessel_eqn,tolerance)
sec_zero_4 = root_secant(5.3,7,bessel_eqn,tolerance)
sec_zero_5 = root_secant(7,8,bessel_eqn,tolerance)
plt.plot(bessel_sub_x,bessel_sub);
plt.plot(bessel_sub_x,[0]*num);
plt.plot(sec_zero_1[0],sec_zero_1[1],'ro');
plt.plot(sec_zero_2[0],sec_zero_2[1],'ro');
plt.plot(sec_zero_3[0],sec_zero_3[1],'ro');
plt.plot(sec_zero_4[0],sec_zero_4[1],'ro');
plt.plot(sec_zero_5[0],sec_zero_5[1],'ro');
print("Roots: ",sec_zero_1[0],sec_zero_2[0],sec_zero_3[0],sec_zero_4[0],sec_zero_5[0])
plt.title("Roots of the Bessel equation using the secant method",fontsize = 20);
plt.legend(["$J_0(x)Y_0(x) - J_2(x)Y_2(x)$","$y=0$","First Five Roots"],fontsize=20);
Roots:  0.6943909034170117 2.924562069568 4.574827154845897 6.181686491287851 7.773378468163644
No description has been provided for this image

Comparison of function calls¶

The secant method seems to be the most effective overall, as long as one can wisely choose the points to begin the algorithm such that it converges on the correct answer. Part of the reason that Regula Falsi is more effective than bisection may be because this particular function looks linear near its roots.

In [64]:
print("Root\t\tBisection\tRegula Falsi\tSecant")
print(str(round(bis_zero_1[0],7))+"\t"+str(bis_zero_1[2])+"\t\t"+str(rf_zero_1[2])+"\t\t"+str(sec_zero_1[2]))
print(str(round(bis_zero_2[0],7))+"\t"+str(bis_zero_2[2])+"\t\t"+str(rf_zero_2[2])+"\t\t"+str(sec_zero_2[2]))
print(str(round(bis_zero_3[0],7))+"\t"+str(bis_zero_3[2])+"\t\t"+str(rf_zero_3[2])+"\t\t"+str(sec_zero_3[2]))
print(str(round(bis_zero_4[0],7))+"\t"+str(bis_zero_4[2])+"\t\t"+str(rf_zero_4[2])+"\t\t"+str(sec_zero_4[2]))
print(str(round(bis_zero_5[0],7))+"\t"+str(bis_zero_5[2])+"\t\t"+str(rf_zero_5[2])+"\t\t"+str(sec_zero_5[2]))
print("Comparison of agreement on location of zero:")
print("Roots (Bis): ","\t"+str(bis_zero_1[0])+"\t"+str(bis_zero_2[0])+"\t"+str(bis_zero_3[0])+"\t"+str(bis_zero_4[0])+"\t"+str(bis_zero_5[0]))
print("Roots (RaF): ","\t"+str(rf_zero_1[0])+"\t"+str(rf_zero_2[0])+"\t"+str(rf_zero_3[0])+"\t"+str(rf_zero_4[0])+"\t"+str(rf_zero_5[0]))
print("Roots (Sec): ","\t"+str(sec_zero_1[0])+"\t"+str(sec_zero_2[0])+"\t\t"+str(sec_zero_3[0])+"\t"+str(sec_zero_4[0])+"\t"+str(sec_zero_5[0]))
Root		Bisection	Regula Falsi	Secant
0.6943909	20		20		9
2.924562	23		11		7
4.5748272	23		8		8
6.1816856	20		8		9
7.7733765	17		7		7
Comparison of agreement on location of zero:
Roots (Bis):  	0.694390869140625	2.9245619773864746	4.574827194213867	6.181685638427735	7.77337646484375
Roots (RaF):  	0.6943909809434807	2.9245621822225516	4.574827154845897	6.181686669178312	7.773378537069607
Roots (Sec):  	0.6943909034170117	2.924562069568		4.574827154845897	6.181686491287851	7.773378468163644

And as a final note: The agreement goes to about 5 or six decimal places, so I'm confident that each method is executing properly.